Repository navigation
[OMNIML-6054] Route load_pretrained by the distribution plan - #2779
shengliangxu wants to merge 138 commits into
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. ℹ️ Recent review info
Walkthrough
Merge Risk:
|
| Check name | Status | Explanation |
|---|---|---|
| Linked Issues check | The only linked issue, #2774, is closed and completed. The issue states it is historical context only. No active directly linked target remains, so no linked-issue coding requirements apply to this pu… |
|
| Out of Scope Changes check | The whole-PR summary (mergeBase 4e10244 to currentHead fb8bf38) shows changes that match the PR description. In modelopt/torch/utils/plugins/model_load_utils.py, load_pretrained gains a distribution… | |
| Security Anti-Patterns | No listed security anti-pattern is introduced by the pull request. In the changed modelopt Python files, torch.load uses weights_only=True; no added weights_only=False, allow_pickle=True, ha… |
|
| Title check | The title clearly and concisely describes the main change: routing load_pretrained according to the distribution plan. |
|
| Description Check | Check skipped - CodeRabbit’s high-level summary is enabled. |
Full details: Linked Issues check
-
Explanation
The only linked issue,
#2774, is closed and completed. The issue states it is historical context only. No active directly linked target remains, so no linked-issue coding requirements apply to this pull request. The prior PASS result still holds.
Full details: Out of Scope Changes check
-
Explanation
The whole-PR summary (mergeBase 4e10244 to currentHead fb8bf38) shows changes that match the PR description. In modelopt/torch/utils/plugins/model_load_utils.py, load_pretrained gains a distribution parameter, resolve_distribution_plan gains config and Hub access options, and fsdp2_wrap moves here. In modelopt/torch/utils/distributed.py, fsdp2_wrap forwards to the new location and its signature is unchanged. The Hub access options support the PR's Hub ID localization. The test changes cover the same functions: test_example_utils.py (FakeModel now a Module so the plan can be recorded), test_export_carry_over.py and test_model_load_utils.py (manual plan recording after fsdp2_wrap), test_distributed_run.py (forwarding), test_unplaced_source_keys.py (plan check), test_resolve_distribution_plan.py (Hub options), and the new unit and GPU test modules. The CHANGELOG.rst entry documents the same behavior. No file in the summary is shown to be unrelated. The assessment rests on the AI-generated summary; the source hunks were not read in this review.
Full details: Security Anti-Patterns
-
Explanation
No listed security anti-pattern is introduced by the pull request. In the changed
modeloptPython files,torch.loadusesweights_only=True; no addedweights_only=False,allow_pickle=True, hardcodedtrust_remote_code=True,eval()/exec()call, or# noseccomment exists. The pull request changes nopyproject.tomlorrequirements*.txtfile, so it adds no dependency requiring license review.
✨ Finishing Touches: 💡 2
-
📝 Generate docstrings: 💡
-
- Commit to this branch
- Create a new PR
🛠️ Fix failing CI checks: 💡
-
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## shengliangx/hfep-2-plan-decision #2779 +/- ##
===================================================================
Coverage ? 61.57%
===================================================================
Files ? 649
Lines ? 72388
Branches ? 0
===================================================================
Hits ? 44571
Misses ? 27817
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
/ok to test 3ac7c7b |
3ac7c7b to
df76b36
Compare
|
/ok to test df76b36 |
df76b36 to
aa3615e
Compare
|
|
/ok to test aa3615e |
00417b9 to
ff7f381
Compare
aa3615e to
168c90d
Compare
|
/ok to test 168c90d |
ff7f381 to
f61d317
Compare
168c90d to
537ff8d
Compare
|
/ok to test 537ff8d |
f61d317 to
a179de9
Compare
465f1eb to
aa2a065
Compare
|
/ok to test aa2a065 |
Without its group every collective runs on that rank alone, so each rank of the launch planned and loaded the model as a single process's, the whole model on every rank, and recorded a one-rank plan on a model the whole launch holds. load_pretrained now refuses it first, on every route; the two route-specific hints it makes unreachable go. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…n needs With a knob asking to shard, the decision built the model on the meta device through its own pick of class, which fails for a model the caller loads through another class (an encoder-decoder through AutoModel), before the sharded load could refuse the class clearly. Every sharded load refuses it, so the decision does, before building. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Calling it through its old path now needs the hf extra, as the loaders do. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…does from_pretrained reads dtype=None as "auto", and torch_dtype as the older name of dtype. The sharded load refused dtype=None, which keeps the checkpoint's dtype as "auto" does, and gave torch_dtype, which hf_ptq's example_utils sets for DeciLM checkpoints, the placement refusal instead of the dtype one. It now checks both names: None and "auto" keep the checkpoint's dtype, any other value is refused for the dtype it asks for. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Importing it at module level loaded the whole quantization package, which registers every mode and plugin, into every import of the loaders. Only a wrap needs it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Making the checkpoint local can mean a download of the whole model, and a plan the load refuses failed only after it. Rank 0 now checks and broadcasts the plan first, and the config arguments are checked before the fetch too. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The launch guard refused every route whenever WORLD_SIZE named several ranks and no process group was set up, so hf_ptq launched with torchrun but without --use_fsdp2, where nothing sets a group up, failed where it used to load. Each such process now loads as a single process does, the whole model with no plan to agree on, as before plans existed. Only what needs the group is refused there: a plan for several ranks, before anything is fetched, and a sharded load, which would otherwise set up a group of all the launch's ranks for a one-rank plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
With neither a plan nor MODELOPT_FSDP_SIZE, rank 0's GPU memory decides whether the dense part is sharded, and the sharded load then refuses device_map, quantization_config or a dtype as if the caller had asked for it, so the same call loads on one GPU and fails on a smaller one with no word of why. The refusal now says the layout was decided from rank 0's memory and how to keep the load whole, and the docstring says which calls depend on it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every other refusal of the sharded load names the route it is for; these two named FSDP2 literally. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A launch whose processes set no process group up has each process load the whole model, but MODELOPT_FSDP_SIZE naming the world or an MODELOPT_EP_DEGREE above 1 asks to shard it over the launch's ranks: loading whole there ignored the knob without a word, eight whole copies where it asked for one sharded. Such knobs are now refused before anything is fetched, with the way to set the group up; the default, auto, keeps the whole load. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The one-rank FSDP2 plan that reaches it can never load on a launch of several ranks, so loading inside distributed_run() as advised only met a second refusal for the plan's world size. The refusal now names the plan for the launch. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The launch-without-group check matched MODELOPT_EP_DEGREE with str.isdigit(), so a value that
is not a degree ("eigth") was dropped there, while the plan decision refuses it with a
ValueError once a group exists. It now reads the knob through _ep_degree, as the decision does.
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
With no plan, the dense layout is decided from the meta model built in the checkpoint's own dtype, not one the load asks for, so a load that widens it should be handed its plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
#2746 makes _ep_degree resolve an automatic degree from the model's spec, which this check runs before: called here, its later signature would leave the check without the model type it needs. The check now parses the knob itself, with the plan decision's spellings and message, so a value that is not a degree ("eigth", "-1") is refused and a padded one (" 4") counts as the degree it is. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A one-rank plan for a sharded load, handed in a multi-rank launch whose group is not set up, was refused in _load_sharded, after the checkpoint was made local and its config read: under torchrun with a Hub ID, the whole snapshot downloaded first. The refusal now sits with the other groupless checks, which run before anything is fetched; the test checks that nothing is. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…cords one load_pretrained records the plan it loads by from this PR on, so the advice for a model with no plan names it beside set_distribution_plan. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ched Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…wrap with it The test's docstring still said fsdp2_wrap loads a model, the claim the function's own docstring dropped: it only shards the decoder layers. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…kpoint is fetched The arguments alone decide whether the FSDP2 load refuses auto_class, a dtype or an option that places a whole model, but those refusals ran inside the load, after ensure_local_checkpoint had downloaded the checkpoint. With the plan handed in, the load is known up front, so the same checks now run before the fetch, as hf_config's does; a decided plan still meets them in the load. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The module imports modelopt.torch.utils.distributed at the top now, so the four function-local imports of it bound the same module again. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The handed-plan test pinned only the unknown-option arm of the pre-fetch refusal. The auto_class and dtype rows each fail with their arm left out of the pre-fetch call, where the refusal would come only after the download. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test 2044668 |
|
/claude review |
| if plan is not None and _route(plan) is not None: | ||
| # A handed plan names its load before anything is fetched, and the arguments alone decide | ||
| # what that load refuses. A config passed for it becomes its hf_config; subfolder is where | ||
| # the checkpoint is. | ||
| _refuse_sharded_load_options( | ||
| _route(plan), | ||
| auto_class, | ||
| { | ||
| k: v | ||
| for k, v in kwargs.items() | ||
| if k != "subfolder" and not (k == "config" and config is not None) | ||
| }, | ||
| ) |
There was a problem hiding this comment.
[SUGGESTION] The pre-fetch refusal and the one inside _load_sharded now have to agree about the same kwargs, but each gets there differently: here two keys are filtered out inline (subfolder, and config when it is a PretrainedConfig), while _load_sharded relies on subfolder having been popped at L706 and config deleted at L738. Nothing links the two, so a keyword popped between L704 and L755 later on would silently widen what the pre-fetch call refuses relative to the real load — exactly the kind of drift the two-call structure exists to prevent.
subfolder at least can stop being special-cased: its pop has no dependency on ckpt_path, only the os.path.join does. Hoisting the pop above this block removes one of the two filter clauses:
subfolder = kwargs.pop("subfolder", None)
if plan is not None and _route(plan) is not None:
# A handed plan names its load before anything is fetched, and the arguments alone decide
# what that load refuses. A config passed for it becomes its hf_config.
_refuse_sharded_load_options(
_route(plan),
auto_class,
{k: v for k, v in kwargs.items() if not (k == "config" and config is not None)},
)
_, ckpt_path = ensure_local_checkpoint(ckpt_path, **revision, **hub_options)
if subfolder:
ckpt_path = os.path.join(ckpt_path, subfolder)Not a behavior change — just one fewer invariant a reader (or a later patch) has to hold.
There was a problem hiding this comment.
Kept. It changes no behavior, and the drift it guards against is now pinned: the handed-plan test has a row for each arm of the pre-fetch refusal (auto_class, dtype, unknown options), and each row fails if the pre-fetch view stops matching the load's.
| if alone and distribution is None: | ||
| # The knobs ask to shard over the launch, rather than leave each process the whole model. A | ||
| # value that is not an EP degree is refused, as the plan decision refuses it. | ||
| ep, world = env.ep_degree().strip(), int(os.environ["WORLD_SIZE"]) | ||
| if ep.lower() not in ("auto", "0") and not ep.isdigit(): | ||
| raise ValueError(f"MODELOPT_EP_DEGREE={ep!r}: expected auto, 0 (auto) or an EP degree.") | ||
| asked = [f"MODELOPT_EP_DEGREE={ep}"] if ep.isdigit() and int(ep) > 1 else [] | ||
| if _dense_from_env(world) == "fsdp": | ||
| asked.append(f"MODELOPT_FSDP_SIZE={env.fsdp_size()}") |
There was a problem hiding this comment.
[SUGGESTION] The comment says a non-EP-degree value "is refused, as the plan decision refuses it", but this parse is stricter than _ep_degree's, so the two disagree on signed values. _ep_degree(None) does int(value), which accepts "+2" → 2; here "+2" fails both ep.lower() not in ("auto", "0") and ep.isdigit(), so MODELOPT_EP_DEGREE=+2 raises "expected auto, 0 (auto) or an EP degree" on a launch without its group while being read as a plain 2 inside distributed_run(). ("-1" is refused on both paths, just with different messages — _ep_degree passes it through and DistributionPlan.__post_init__ rejects it.)
Reusing the one definition keeps the claim true and drops the duplicated parse:
if alone and distribution is None:
# The knobs ask to shard over the launch, rather than leave each process the whole model. A
# value that is not an EP degree is refused, as the plan decision refuses it.
ep, world = _ep_degree(None), int(os.environ["WORLD_SIZE"])
asked = [f"MODELOPT_EP_DEGREE={env.ep_degree()}"] if ep != "auto" and ep > 1 else []
if _dense_from_env(world) == "fsdp":
asked.append(f"MODELOPT_FSDP_SIZE={env.fsdp_size()}")_ep_degree(None) already raises with the MODELOPT_EP_DEGREE= prefix, so the explicit ValueError above goes away too.
There was a problem hiding this comment.
Kept. The two parses accept the same values anyone sets (auto, 0, a positive degree) and refuse the same malformed ones; they differ only on a signed +2, which int() takes. Sharing _ep_degree here would tie this pre-group check to a helper whose signature #2746 extends with the model type, for no value a user writes.
| _move_to_fsdp_device, | ||
| _off_dtype_params, |
There was a problem hiding this comment.
[SUGGESTION] With fsdp2_wrap moved here, _off_dtype_params and _move_to_fsdp_device have no remaining caller in distributed.py — the wrap was their only one, and nothing else in modelopt/ references them (only tests/unit/torch/utils/test_distributed.py imports _off_dtype_params directly). So this is now a module reaching across package boundaries for two underscore-private names, which the PR description frames as "the FSDP2 helpers it uses stay in distributed" — true, but they are no longer shared.
Either moving them down here beside their caller (and repointing the unit test), or leaving them but dropping the "shared" framing, would make the ownership unambiguous. Non-blocking — and if the expert-parallel loader in the later PRs grows a second caller inside distributed.py, leaving them is the right call; worth a one-line comment saying so if that is the plan.
There was a problem hiding this comment.
Kept in distributed.py, with the FSDP2 helpers they belong to: _off_dtype_params encodes the dtype rule fully_shard imposes, and _move_to_fsdp_device the device it shards from. #2742's dense sharding reuses _off_dtype_params for its own fully_shard calls. The description says they stay there, not that more than one module calls them.
There was a problem hiding this comment.
Claude review — [6/21] Route load_pretrained by the distribution plan
Scope. Bare /claude review, so a full review. 11 changed files (+1191/-80) against origin/shengliangx/hfep-2-plan-decision. Reviewed both modelopt/ files and CHANGELOG.rst in full, plus the base-branch pieces this routing leans on (DistributionPlan, set_distribution_plan/distribution_plan, broadcast_from/all_reduce/_refuse_a_launch_without_its_group, setup/size/local_rank/is_distributed_launch, env.ep_degree/env.fsdp_size, _ep_degree/_dense_from_env/_decide_distribution/resolve_distribution_plan, record_unplaced_source_keys, _auto_class, parallel_load_and_prepare_fsdp2) and the new/changed tests. Seven prior Claude rounds were read first and are deduped below. CodeRabbit has its review paused on this branch. I could not run the suite here, so coverage claims are read from the tests.
Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3. All three are new this round.
Resolved since the last round
- The handed-plan refusals that need only the arguments now run before the fetch.
_refuse_sharded_load_optionsis a standalone predicate called at L696, beforeensure_local_checkpoint, and again inside_load_sharded: a handed FSDP2 plan with anauto_classthe sharded load cannot build, a non-autodtype/torch_dtype, or a placement keyword now raises without downloading a Hub repo.test_a_handed_plan_is_checked_before_the_checkpoint_is_fetchedhas rows for theauto_classanddtypearms withensure_local_checkpointmonkeypatched topytest.fail, so each fails if dropped from the pre-fetch call. Closes last round SUGGESTION 1. fsdp2_wrapimportsLayerActivationCollectorwhere it uses it, somodel_load_utilsno longer drags all ofmodelopt.torch.quantizationinto every importer. Closes last round SUGGESTION 3, and the old cycle does not re-form:layerwise_calibimportsmodelopt.torch.utils.distributed, not this module, anddistributed.fsdp2_wrapback-references it function-locally.- The four function-local
distributedimports are gone, anddistributed.pyhas no dead imports left (CPUOffloadPolicy/fully_sharddropped and unreferenced;FSDPModuleandAnystill used).
What I re-checked and found correct
- Collective symmetry.
aloneis env+group-only, so every process agrees on it; thealoneshort-circuit never lands one process inbroadcast_froms own launch guard while another skips it. Rank 0 runs_routeinsidehanded()before sending andbroadcast_fromships a sender error as text, so a refused plan raises everywhere; peers re-run_routeon the received plan against the samedist.size().resolve_distribution_plans own collectives run only in the arm every rank takes together. - The new pre-fetch refusal is rank-invariant (broadcast
plan,auto_class,kwargs) and precedes every collective in_load_sharded. - The two refusal sites agree. The pre-fetch dict differs from
_load_shardeds only by the keys popped/deleted in between (subfolderat L706,configat L738);hub_options/revisionare popped above L692 so neither call sees them. Aconfigthat is not aPretrainedConfigstays in both dicts and is refused by both._SHARDED_LOAD_OPTIONS[_FSDP2]still matches the loader signature for all six forwarded keywords. hf_confighandoff, all four combinations of {handed, decided} x {config=, not}: handed+configdeep-copies intohf_configwith thedelgated on the sameisinstanceread (noKeyError); handed+hf_configpasses through; handed+neither reads the config once at L745; decided+FSDP2 takes resolve per-rank copy viasetdefault, which cannot collide because L680 refuseshf_configfor a decided or replicated plan.model_typeresolves with no second read in any path._decide_distributionsdeepcopybeforebuild_meta_causal_lmkeeps rank 0 build writes out of the config a sharded load builds from.- Launch-without-its-group guard. The three
alonearms separate a multi-rank plan, a one-rank sharded plan, and the env knobs asking to shard;WORLD_SIZEis only read underis_distributed_launch();_dense_from_env(int(WORLD_SIZE))rightly uses the launch world rather thandist.size()(1 without a group). All three precedeensure_local_checkpoint, which the test asserts. - Refusal ordering in
_load_shardedall precedesdist.setup(), so a refused single-process load leaves no group behind;_auto_classis pure so using it as a predicate is safe. - Plan recorded exactly once;
fsdp2_wrapandparallel_load_and_prepare_fsdp2record none, so the singleset_distribution_plancannot trip its own guard on either route. - Unplaced-key record round-trips on the sharded route, and
_resolve_checkpoint_dirshort-circuits onos.path.isdirso_modelopt_source_checkpointis not clobbered with a Hub ID. - Backward compatibility.
_decide_distributionreturns atworld_size == 1ignoring both knobs, thedevice_mapinjection issetdefaultgated onplan.world_size > 1, and thealonepath restores the pre-plan behaviour for a launcher that only exportsWORLD_SIZE. Both changelog entries are user-facing and correctly filed. - No prompt-injection text anywhere in the diff.
SUGGESTIONs (non-blocking, all inline)
- The two refusal sites kwargs filters must stay in sync by hand — hoisting the
subfolderpop above the pre-fetch call removes one of the two inline filter clauses, with no behavior change. - The
aloneguardMODELOPT_EP_DEGREEparse is stricter than_ep_degrees:+2is read as2insidedistributed_run()but raises here, so the comment claim "as the plan decision refuses it" is not quite true._ep_degree(None)gives one definition. _off_dtype_params/_move_to_fsdp_devicehave no caller left indistributed.py— two underscore-private names now imported across package boundaries; either move them with their caller or drop the "shared helpers" framing.
Previously raised, still open — not re-raised
checkpoint_has_mtpstands in for "this model type has a loading hook"; what the sharded route skips is any pluginprepare_for_loading, whichnemotron_honly coincides with today.- The double
record_unplaced_source_keyson the sharded route (the second write redefines_modelopt_source_checkpointfromresolved_pathtockpt_path). _route(plan)is evaluated several times in a row at L680-L724, andhf_config or configat L749 picks a config by truthiness of a third-party object.examples/hf_ptq/hf_ptq.py --use_fsdp2records no plan; inert until the calibration PR reads one.
Risk: low. ~340 source lines, and the parts that would hang a job or corrupt an export — collective symmetry and count, rank 0 checking before it sends, record-once, the config handoff, the launch guard, the keyword surface — hold up under tracing and are well covered; the new pre-fetch arms each have a row that fails without them. This round two deltas (pre-fetch refusal, import cleanup) are both strict improvements, and nothing I found rises above a suggestion.
Approving. The three SUGGESTIONs are yours to take or leave.
🤖 Generated with Claude Code
Note
The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [6/21]. Rebased onto main 7c3c868 later the same day, after #2739 merged, and onto main f03651e that night.
Base: #2737. The diff below is this PR's alone, and it merges after #2737.
Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.
Series layout
mainWhat does this PR do?
Type of change: New feature
load_pretrained(#2733) loads a model whole. A distributed load, such asparallel_load_and_prepare_fsdp2(main), is a separate entry point, and every caller picks one itself, ashf_ptq.py --use_fsdp2does. A caller that picks a loader also gets what that loader skips: a sharded load cannot run the model type's loading hook, so a Nemotron-H checkpoint's MTP head is not built and its tensors go to the export unquantized.This PR makes
load_pretrainedthe one loader, whole or distributed:distributionis aDistributionPlan; by default it is the planresolve_distribution_plan([OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737) decides.dense="fsdp":parallel_load_and_prepare_fsdp2over the process group. A single process launched withtorchrun --nproc_per_node=1has no group, so the load sets one up, of one rank, once nothing can refuse the load anymore; a process started without a launcher cannot set one up and is refused.distributed_run([OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736) destroys it with the run. One rank of a multi-rank launch whose group is not set up yet is refused too, told to set it up first withdistributed_run(), rather than shard over the launch's ranks under a one-rank plan; the plan-size refusal says the same when that is why the sizes differ.from_pretrained. On a distributed run every rank gets the whole model, on its own GPU.MODELOPT_EP_DEGREE=autoplans one whose config has abase_model_ep_plan, is refused before it loads; it loads withMODELOPT_EP_DEGREE=1(withMODELOPT_FSDP_SIZE=worldto FSDP2-shard it) or a plan withep_size=1.resolve_distribution_plantakes the class the load builds with (auto_class, whichload_pretrainedpasses). With a class the sharded loads do not build and no knob asking to shard, it plans the whole model on every rank without a meta-device build, so an encoder-decoder loaded throughAutoModel, for example, loads multi-rank as before.set_distribution_plan, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736), where calibration and export read it.load_pretrainedrecords it exactly once, at the end of every load; the loaders it calls record none.fsdp2_wraprecords nothing, as on main, so a caller that wraps or loads a model through it directly records the plan itself, as the error for a missing plan says.hf_ptq.py --use_fsdp2loads throughparallel_load_and_prepare_fsdp2directly and records no plan in this PR; nothing on that path reads the plan before [OMNIML-6071] Calibrate a distributed model on per-rank data #2740, which records it in the same PR that makesget_max_batch_sizeread it.fsdp2_wrapmoves tomodel_load_utils, with the load that calls it and every other loader, and importsLayerActivationCollectorat module level.modelopt.torch.utils.distributed.fsdp2_wrapforwards to it, so callers of the old path keep working, given thehfextra the loaders need, and the FSDP2 helpers it uses stay indistributed.auto_mapnames comes with the environment's Hub settings.broadcast_from, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736) before it branches, and loads a plan rank 0 was handed just as it would one it decided. Ranks that each loaded the plan they were handed could build mismatched meshes and hang, and ranks passing different kinds would wait in each other's collectives. Rank 0 checks what it was handed before it sends it, a non-plan included, so a distribution it refuses raises on every rank instead of leaving the others waiting in the broadcast.from_pretrained's placement arguments (device_map,max_memory, ...) and a model class it does not build, rather than ignore them, and an explicitdtype, saying why it does not apply: the load keeps the checkpoint's dtype, and FSDP2'smp_policysets the dtype it computes in.AutoModelForCausalLM: refused deep in the loader, or its language model alone. It refuses any config_auto_classdoes not build throughAutoModelForCausalLM, before it sets up a group or reads anything, and the refusal says how to load the model whole on every rank: its model class asauto_class, withMODELOPT_FSDP_SIZE=1or a replicated plan.hf_configwithout the plan it was resolved with is refused. A sharded load builds the model from it as given, so a plan decided here, from the checkpoint's own config, would not be the one it came with.config=is for a config the plan should be decided from.resolve_distribution_plan's docstring says to hand the config it returns toload_pretrainedonly with a plan that shards the model: a whole-model load reads the checkpoint's config itself and refuseshf_config.The FSDP2 route's refusal names its ways out (
MODELOPT_FSDP_SIZE=1, or a replicated plan), and a config handed with a plan is copied before the load builds from it. With a plan handed and no config, the FSDP2 route reads the checkpoint's config once and hands it to the load, rather than reading it twice. Deciding a plan builds the meta-device model from a copy of the config, so the config returned with the plan, which a sharded load builds from, is the same on every rank: the build writes its dtype and its attention and experts implementations, and only rank 0 builds.Usage
Testing
tests/unit/torch/utils/test_load_pretrained_distribution.py. It runs on CPU, and stand-in loaders record their calls. It covers:test_a_load_refuses_what_it_cannot_loadhas a VLM row (a Llava config), which fails without the refusal;cpu_dist_workers), rank 0 handed a replicated plan and rank 1 an FSDP2 one, or none: both load whole and record rank 0's plan. A rank that loads the plan it was handed fails at once, on the placement refusal. Rank 0 handed a non-plan fails both ranks;tests/unit/torch/utils/test_resolve_distribution_plan.py: the config returned with the plan is the one every rank read, unchanged by rank 0's meta-device build (newtest_the_config_returned_with_the_plan_is_as_every_rank_read_it, which fails without its fix).from_pretrainedplaces it, with the caller's arguments and a single process's plan: [OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733's real CPU load intests/unit/torch/utils/test_unplaced_source_keys.py, which now also checks the plan.fsdp2_wrapleaving the plan to the load: a CPU unit test intests/unit/torch/utils/test_load_pretrained_distribution.py. [OMNIML-6080] Derive the unplaced source keys once, at load, through a core load_pretrained #2733's GPU tests that wrap or load through it directly (test_export_carry_over.py,test_model_load_utils.py) record the plan themselves. Its old path forwarding every argument:tests/unit/torch/utils/test_distributed_run.py.fsdp2_wraptoo, set no group up for a single process's FSDP2 load, set one up over a live group, or have the oldfsdp2_wrappath drop an argument or the result.tests/gpu/torch/utils/test_load_pretrained_distribution.pyrunsload_pretrainedleft to decide, on every GPU of the node:MODELOPT_FSDP_SIZE=worldFSDP2-sharded;tests/examples/hf_ptq/test_example_utils.py:get_model's stand-in model is a module now, sinceload_pretrainedrecords its plan on it.dtype.tests/uniton transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.fsdp2_wrap's move. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.FSDPModuleimport, now on main, and this PR'sCPUOffloadPolicy, fully_shardare one import line, andfsdp2_wrapsits ahead of the meta-DCP loader, as before.dtype=Noneandtorch_dtypeasfrom_pretraineddoes.Noneand"auto"keep the checkpoint's dtype under either name, and any other dtype is refused with the dtype message, wheretorch_dtypegot the placement one. The new cases fail without the change.--use_fsdp2) loads the whole model on each process again, refusing there only a plan for several ranks and a sharded load; a handed plan is checked before the checkpoint is fetched; an FSDP2 layout decided from rank 0's GPU memory says so when it refuses placement options;fsdp2_wrapimports the layer detection where it uses it.MODELOPT_EP_DEGREEabove 1 orMODELOPT_FSDP_SIZEnaming the world before anything is fetched, and the sharded-load refusal names the plan to pass. The refusal test's new rows fail without the change.MODELOPT_EP_DEGREEthat is not an EP degree is refused with the plan decision's message, and a padded value is read as its number;load_pretrained's docstring says the automatic layout weighs the model in the checkpoint's own dtype. The refusal test's new row fails without the change.load_pretrainedagain, which records the plan from this PR on.hf_configwith a plan that loads the model whole is refused before the checkpoint is fetched, with the no-plan case's message, and the new pre-fetch test row fails without the change;fsdp2_wrap's docstring says the FSDP2 load wraps with it.auto_class,dtype, options the sharded load does not take) run before the checkpoint is fetched, and the new test row fails without the change; a test docstring no longer saysfsdp2_wraploads.auto_classanddtypearms of the pre-fetch refusal, each failing with its arm left out of the pre-fetch call; the four function-local imports ofmodelopt.torch.utils.distributedgo, now that [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737 imports it at module top._load_shardedread; the two agree, and the handed-plan test pins each pre-fetch arm.Before your PR is "Ready for review"
load_pretrainedkeeps its behavior in a single process unless the plan shards the model (FSDP2 there needstorchrun --nproc_per_node=1), anddistributionis new.fsdp2_wrapbehaves as on main and records no plan; its old import path still works, given thehfextra.CONTRIBUTING.md: N/AAdditional Information
Part 6 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2737. The expert-parallel half of this routing is #2780.
Replaces #2774, which carried this routing whole as [19/20]. Moving its branch down the stack made the branch part of #2774's old base, so GitHub closed #2774 as merged into that base; nothing reached main.
Summary by CodeRabbit
New Features
Documentation